iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

本日程式碼:repo tag day-22

先複習一下用詞(我其實寫到後面有時候都混淆了):

  • Run(一輪):所有 payment 一次性簽名完成的東西。整份 300 筆名單收成一棵樹,payer 的一個簽名蓋住整輪。程式裡就叫 PayoutRun,鏈上是一個 run PDA。
  • Batch(一批):一筆交易裝得下的內容;Solana 上是對齊的 8 筆,300 筆切成 38 批。一批一筆交易。
  • Transaction(交易):批的內容加上 envelop(就是 blockhash 和簽名等等的)。所以 38 批對應 38 筆 relayer 交易。

好,現在一份 300 行的撥款檔案,第 146 行跟第 145 行寫的是同一筆 intent。這一行走到鏈上會發生什麼事?在 EVM 上,settleBatch 是一項失敗整個 batch 回滾,偏偏同一把 ref 被取用第二次就是一種失敗。整批一起退回來,跟它無關的幾百筆也停在原地。在 Solana 上更糟:昨天的 run 只認 payer 簽過的 root,兩行都在樹上就是兩片各自合法的葉子,同一筆 intent 會照付兩次。今天要決定的是這一行該在哪一關被攔下來,以及攔下來之後,同一份檔案裡沒問題的那幾百行怎麼辦。

今天的目標

我們今天做的東西還是在鏈下,而且比組成 batch 更前面一步。它叫 intake,把一份 CSV 讀成一份可以送出去的名單,順便交代它剔掉了哪幾行、為什麼剔。切成 batch 那一段已經有人管了,今天要補的是「這份名單憑什麼可以送」。而在 Solana 那條路上它還多背一件事:驗完收下來的名單,就是 payer 要簽進 root 的那份名單,行的順序就是葉子的順序,init_run 之前的最後一關就是這裡。

動筆之前先看別人怎麼處理 partial failure:

  1. RFC 4180 是 CSV 的格式規格,第 2 節第 4 點寫「Each line should contain the same number of fields throughout the file.」:欄位數不對這件事在格式這一層就有答案了,還輪不到我們去看內容合不合理
  2. Modern Treasury 的 Bulk Requests 講一次操作一整批資源要怎麼回報:「Modern Treasury generates one Bulk Result for each row in the resources array. As each array item is processed, Modern Treasury moves the associated Bulk Result to either the successful or failed state…」,一行一個結果,成功與失敗分兩堆存
  3. AWS Lambda 處理 SQS batch 的文件則是把預設值講明白:「When your Lambda function encounters an error while processing a batch, all messages in that batch become visible in the queue again by default, including messages that Lambda processed successfully.」,想留下 partial success 得自己把失敗的項目一項一項報回去,回傳一份 batchItemFailures 清單

做完之後 repo 中 internal/intakeExample_readAPayoutFile 會拿同一份 300 行的檔案讀兩次,輸出長這樣:

intake  solana   300 rows  0 accepted  4 rejected
intake: rejected rows and the policy does not skip them: 4 of 300 rows
reject  line 41    amount     "100.00" is not a whole number of minor units
reject  line 89    merchant   "" is not a merchant
reject  line 146   duplicate  pi_0144 already appears on line 145
reject  line 213   fields     the row has 2 fields, want 3
intake  solana   300 rows  296 accepted  4 rejected
plan    solana   296 payouts  37 batches  0 new accounts  rent 0 lamports
trace   batch #11  8 items  csv lines 83-88, 90-91
  • 第一次讀用的是預設的 Policy,所以第一行那個 0 accepted 講的是整份退回這件事。Accepted 被刻意清空了,三百行裡面其實有 296 行合格
  • 那四行壞掉的資料是 Example 自己編的,不是統計出來的比例。真實的撥款檔案會壞幾行、壞在哪一欄,只有拿到真的檔案才知道。這裡編出來的用途是讓五種 reject 理由裡的四種各出現一次
  • 中間那個 37 批是 bulk 照「一批 8 筆、切在 8 的倍數邊界」算出來的,而 8 又是照 Solana 那 1,232 bytes 加上整批共用的證明算的,所以這一行跟那個 8 一樣有保存期限
  • 最後那行的 83-88, 90-91 中間缺了 89,因為第 89 行被剔掉了。有這種缺口在,batch 的第幾項對到檔案第幾行就算不出來

檔案壞掉 vs 某一行壞掉 vs 某一批壞掉

partial failure 有三種,而它們能收拾的範圍差很多:

表頭那一關刻意不做任何容錯。表頭一旦對不上,接下來每一欄的意義都是猜的。猜錯的下場是把 intent id 當成 merchant 付出去。所以表頭不對就是整份退回,一行都不讀。(試算表存出來的 CSV 開頭常常帶一個 BOM,那不是表頭寫錯,Read 會先把它修掉再比對。)

一行不合格有五種理由,分成三種形狀:整行的欄位數不對(fields)、某一欄的內容自己不合格(intent_idmerchantamount)、以及跟別的行有關係(duplicate)。duplicate 指的是同一個 intent id 在這份檔案裡出現第二次。它才是這一關存在的理由,因為鏈上攔不到它。EVM 的合約認的是同一把 ref 不能被取用兩次。同一筆 intent 寫成兩行不同的金額會算出兩把不同的 ref,兩行都放行;兩行一模一樣倒是攔得住,收場是整個 batch 回滾。Solana 的 run 連這一半都沒有:payer 把 root 簽下去之後,程式只認「這片葉子在不在樹上、這片付過沒有」,兩行一模一樣就是兩片各自合法的葉子,照付兩次。名單這一關在那條鏈上沒有備援。

五個檢查照順序跑,其中一步的位置是有陷阱的。duplicate 靠一張「看過的 intent id」表比對,那個 id 該在什麼時候記進表?直覺的答案是等一整行都合格才記,畢竟壞掉的行本來就不該算數。但那樣會漏:第 89 行的 merchant 空著、整行被剔掉,pi_0088 沒進表,之後同一個 id 再出現一次就會直接過關,那一筆也就照樣付出去了。報告交出去,operator 照著把第 89 行的 merchant 補好重送,同一筆 intent 就付了第二次。所以 id 是在 intent_id 那一關過了就記,不等後面兩欄。這一行自己合不合格,跟「這個 id 在這份檔案裡已經出現過」是兩件事。

金額那一欄只收最小單位的正整數,100.00 一律退回。這是三欄裡面最容易被說成「順手換算一下就好」的一欄。要換算得先知道這顆 token 有幾位小數,而那不是這一行講得出來的事。

預設值選整份退回

一行壞掉、其餘的行怎麼辦,這一題沒有技術上的正確答案。所以我拿產品的問法問一次:這三條路各自出事之後,誰要接那通電話?

  1. 整份退回:客訴是「錢還沒來」,而系統這邊查得到原因、也答得出什麼時候會來。代價是那四行裡任何一個錯字,都會讓另外 296 筆本來就沒問題的錢跟著停一輪
  2. 跳過壞的行:客訴一樣是「錢還沒來」,可是那份檔案在系統裡是處理完成,要先有人想到去翻被跳過的報告才查得到
  3. 當場修正:沒有客訴,付款完全成功、對帳也對得起來,錯的只有金額。USDC 是 6 位小數,把 100.00 猜成 18 位就是多付一兆倍

沒有客訴才是第三條最麻煩的地方,所以我們完全不做當場修正。前兩條之間我選整份退回當預設值。撥款檔案多半是另一個系統產生的,某一行不合格通常是產生它的那一段壞了,很少是那一個 merchant 本身特別。這種時候先付掉另外 296 筆,等於把一個還沒查清楚的錯誤變成一筆已經送出去的錢。

這個推論靠的是「檔案由系統產生」這個前提。如果你的撥款檔案是每個 merchant 自己填一列寄回來的,一行壞掉就真的只是那一行壞掉,預設值應該反過來。

所以 Skip 這個開關要 operator 看過報告才打得開,而且打開之後還有一個上限:

    if n := len(run.Rejected); n > 0 {
        switch {
        case !p.Skip:
            run.Accepted, run.Lines = nil, nil
            return run, fmt.Errorf("%w: %d of %d rows", ErrRejected, n, run.Rows)
        case p.MaxRejects > 0 && n > p.MaxRejects:
            run.Accepted, run.Lines = nil, nil
            return run, fmt.Errorf("%w: %d rejected, the limit is %d", ErrTooManyRejects, n, p.MaxRejects)
        }
    }

兩條路都把 Accepted 清成空的。這一行看起來多餘,實際上是整個 package 唯一的保險:Read 回的是一份 Run 加一個 error,但呼叫端漏看 error 這件事遲早會發生一次,發生的時候最糟只會送出零筆。

MaxRejects 用絕對數字不用比例。一份三行的檔案壞掉一行是三成三,一份三千行的檔案壞掉一行是萬分之三,但要人去看的工作量一樣多。用比例的話大檔案的容忍度會跟著長大,而大檔案正是最不該放寬的那一種。

一批回滾要翻得回檔案的行號

上鏈之後就沒有「哪幾筆成功」這種問題了:一批全成或全敗,所以鏈上唯一會出現的 partial failure,單位就是一批。麻煩的是這個單位在檔案上沒有名字。

operator 手上有的是那份 CSV,而 Plan 只有我們自己看得到,所以 Trace 要回答的是「第 11 批對到第幾行」。這個對應算不出來:中間有四行被剔掉了,第幾批第幾項跟第幾行之間差多少,取決於前面剔了幾行。Run 因此在收下每一筆付款的同時記著它來自第幾行,Trace 只是把兩份順序疊在一起數過去。

能這樣數,是因為 bulkPack 照名單原本的順序切、不重排也不丟項。不重排本來只是為了讓報告讀得懂,昨天它多背了「名單的順序就是葉子的順序」,今天它又變成 Trace 算得對的前提。哪天有人把 Pack 換成裝箱演算法,Trace 不會失敗,它會安靜地給出一串看起來完全正常的錯誤行號。所以它自己先檢查兩件事:這份計畫的筆數跟這份 Run 收下的一不一樣,以及每一批的每一項對到的 merchant 是不是同一個。前者擋數量,後者擋順序,難發現的一直是順序。

至於那八行回來之後怎麼辦,分類早就做好了:retryable 就原本那個 batch 重送,poison 就把該剔的那幾行剔掉、重新切一次 batch 再送。重新切會讓後面每一批的邊界整個位移,舊的 Plan 跟著作廢。在 Solana 上更是整棵樹都換了一棵,root 要重簽、run 要重開。Trace 認的是「這份 Plan 配這份 Run」這一組,所以重切完要連 Run 一起換一份新的,不能拿新的 batch 編號去查舊的行號。

小結

到目前為止,EVM 與 Solana 的差別都還靠一句「那條鏈的規則不一樣」(的爛藉口?)擋在各自的常數裡。明天來看看這兩條鏈的差異該用什麼形狀的介面收起來。

明天見。


上一篇
Day 21 | Solana 批量分發的三條路線:PDA Vault、Merkle Distributor 與圈存簽 root
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言